export type Input = string | URL | Request;
export type SearchParameter = string | string[][] | Record<string, string> | URLSearchParams | undefined;
export type SearchParameterOption = SearchParameter | Record<string, string | number | boolean> | Array<Array<string | number | boolean>>;

// ----------------------------------------const----------------------------------------
// 32 位 int 的最大值
export const MAX_SAFE_TIMEOUT = 2_147_483_647;
// 默认超时时间
export const DEFAULT_TIMEOUT = 30_000;
export const RESPONSE_TYPE = {
    blob: '*/*',
    text: 'text/*',
    json: 'application/json',
    formData: 'multipart/form-data',
    arrayBuffer: '*/*'
} as const;

export const KR_OPTION_KEY: {
    [K in keyof KrOption]?: true
} = {
    hook: true,
    json: true,
    parameter: true,
    timeout: true,
    throwHttpError: true,
    onDownloadProgress: true
};

export const REQUEST_OPTION_REGISTRY: {
    [K in keyof globalThis.RequestInit & {
        method?: string;
        keepalive?: boolean;
        headers?:
            | string[][]
            | Record<string, string | readonly string[]>
            | Headers;
        body?:
            | ArrayBuffer
            | AsyncIterable<Uint8Array>
            | Blob
            | FormData
            | Iterable<Uint8Array>
            | ArrayBufferView
            | URLSearchParams
            | null
            | string;
        redirect?: 'error' | 'follow' | 'manual';
        integrity?: string;
        signal?: AbortSignal | undefined;
        credentials?: 'omit' | 'include' | 'same-origin';
        mode?: 'cors' | 'navigate' | 'no-cors' | 'same-origin';
        referrer?: string;
        referrerPolicy?:
            | ''
            | 'no-referrer'
            | 'no-referrer-when-downgrade'
            | 'origin'
            | 'origin-when-cross-origin'
            | 'same-origin'
            | 'strict-origin'
            | 'strict-origin-when-cross-origin'
            | 'unsafe-url';
        window?: undefined;
        dispatcher?: unknown;
        duplex?: unknown;
    }]?: true
} = {
    method: true,
    headers: true,
    body: true,
    mode: true,
    credentials: true,
    cache: true,
    redirect: true,
    referrer: true,
    referrerPolicy: true,
    integrity: true,
    keepalive: true,
    signal: true,
    window: true,
    dispatcher: true,
    duplex: true,
    priority: true
};

// --------------------------------------support--------------------------------------
export const supportFormData: boolean = typeof globalThis.FormData === 'function';
export const supportResponseStream: boolean = typeof globalThis.ReadableStream === 'function';
export const supportAbortController: boolean = typeof globalThis.AbortController === 'function';
export const supportRequestStream: boolean = (() => {
    let duplexAccessed: boolean = false;
    let hasContentType: boolean = false;
    const supportRequest: boolean = typeof globalThis.Request === 'function';
    const supportReadableStream: boolean = typeof globalThis.ReadableStream === 'function';

    if (supportReadableStream && supportRequest) {
        try {
            hasContentType = new globalThis.Request('https://empty.invalid', {
                body: new globalThis.ReadableStream(),
                method: 'POST',
                // @ts-expect-error - Types are outdated.
                get duplex() {
                    duplexAccessed = true;
                    return 'half';
                }
            }).headers.has('Content-Type');
        } catch (error) {
            // QQBrowser on iOS throws "unsupported BodyInit type" error (see issue #581)
            if (error instanceof Error && error.message === 'unsupported BodyInit type') {
                return false;
            }
            throw error;
        }
    }
    return duplexAccessed && !hasContentType;
})();

// --------------------------------------common--------------------------------------
export type Require<T, K extends keyof T = keyof T> = T & {
    [P in K]?: T[P]
};

export type Mutable<T> = {
    -readonly [P in keyof T]: T[P]
};

export type ObjectEntry<T> = T extends ArrayLike<infer U>
    ? Array<[string, U]>
    : Array<{ [K in keyof T]: [K, T[K]] }[keyof T]>;

// --------------------------------------NormalizeOption--------------------------------------
/**
 * Normalized option passed to the `fetch` call and the `beforeRequest` hook.
 */
export interface NormalizeOption extends RequestInit {
    method: NonNullable<RequestInit['method']>;
    credentials?: NonNullable<RequestInit['credentials']>;
    baseUrl: string;
    onDownloadProgress: Option['onDownloadProgress'];
}

// -----------------------------------http method-----------------------------------
export const REQUEST_METHOD = ['get', 'put', 'head', 'post', 'patch', 'delete'] as const;

// -----------------------------------request, response, headers-----------------------------------
export type KrHeadersInit = NonNullable<RequestInit['headers']> | Record<string, string | undefined>;

export type KrRequest<T = unknown> = {
    json: <J = T>() => Promise<J>;
} & Request;

export type KrResponse<T = unknown> = {
    json: <J = T>() => Promise<J>;
} & Response;

export type ResponsePromise<T = unknown> = {
    blob: () => Promise<Blob>;
    text: () => Promise<string>;
    json: <J = T>() => Promise<J>;
    formData: () => Promise<FormData>;
    arrayBuffer: () => Promise<ArrayBuffer>;
} & Promise<KrResponse<T>>;

// -----------------------------------hook-----------------------------------
export type BeforeRequestHook = (option: NormalizeOption, request: KrRequest) => Request | Response | void | Promise<Request | Response | void>;
export type BeforeErrorHook = (error: HTTPError) => HTTPError | Promise<HTTPError>;
export type AfterResponseHook = (option: NormalizeOption, request: KrRequest, response: KrResponse) => void | Response | Promise<Response | void>;
export type Hook = {
    /**
     * ```js
     * import kr from 'kr';
     *
     * await kr('https://example.com', {
     *     hook: {
     *         beforeError: [
     *             error => {
     *                 const {response} = error;
     *                 if (response && response.body) {
     *                     error.name = 'GitHubError';
     *                     error.message = `${response.body.message} (${response.status})`;
     *                 }
     *                 return error;
     *             }
     *         ]
     *     }
     * });
     * ```
     */
    beforeError?: BeforeErrorHook[];
    beforeRequest?: BeforeRequestHook[];
    /**
     * ```js
     * import kr from 'kr';
     *
     * const result = await kr('https://example.com', {
     *     hook: {
     *         afterResponse: [
     *             (input, option, response) => {
     *                 console.log(input, option, response);
     *                 return new Response('A different response', {status: 200});
     *             },
     *             async (input, option, response) => {
     *                 if (response.status === 403) {
     *                     const token = await kr('https://example.com/token').text();
     *                     option.headers.set('Authorization', `token ${token}`);
     *                     return kr(input, option);
     *                 }
     *             }
     *         ]
     *     }
     * });
     * ```
     */
    afterResponse?: AfterResponseHook[];
};
// ------------------------------------DownloadProgress------------------------------------
export type DownloadProgress = {
    total: number;
    current: number;
    percent: number;
};

// -----------------------------------custom-error-----------------------------------
export class HTTPError<T = unknown> extends Error {
    public option: NormalizeOption;
    public request: KrRequest;
    public response: KrResponse<T>;

    constructor(option: NormalizeOption, request: Request, response: Response) {
        const code = (response.status || response.status == 0) ? response.status : '';
        const title = response.statusText || '';
        const status = `${code} ${title}`.trim();
        const reason = status ? `status code ${status}` : 'an unknown error';

        super(`Request failed with ${reason}: ${request.method} ${request.url}`);

        this.name = 'HTTPError';
        this.option = option;
        this.request = request;
        this.response = response;
    }
}

// -------------------------------------option-------------------------------------
export type DownloadProgressHandle = (progress: DownloadProgress, chunk: Uint8Array) => void;
/**
 * Custom Kr option
 */
export type KrOption = {
    baseUrl?: string;
    json?: unknown;
    hook?: Hook;
    timeout?: number | false;
    throwHttpError?: boolean;
    parameter?: SearchParameterOption;
    /**
     * 下载进度事件处理
     * ```js
     * import kr from 'kr';
     *
     * const result = await kr('https://example.com', {
     *     onDownloadProgress: (progress, chunk) => {
     *         // `0% - 0 of 1271 byte`
     *         // `100% - 1271 of 1271 byte`
     *         console.log(`${progress.percent * 100}% - ${progress.current} of ${progress.total} byte`);
     *     }
     * });
     * ```
     */
    onDownloadProgress?: DownloadProgressHandle;
};

/**
 * Option are the same as `window.fetch`, except for the KrOption
 */
export interface Option extends KrOption, Omit<RequestInit, 'headers'> {
    method?: string;
    /**
     * ```js
     * import kr from 'kr';
     *
     * const original = kr.create({
     *     headers: {
     *         rainbow: 'rainbow',
     *         unicorn: 'unicorn'
     *     }
     * });
     *
     * const extended = original.extend({
     *     headers: {
     *         rainbow: undefined
     *     }
     * });
     *
     * const response = await extended('https://test.com').json();
     *
     * console.log('rainbow' in response);
     * //=> false
     *
     * console.log('unicorn' in response);
     * //=> true
     * ```
     */
    headers?: KrHeadersInit;
}

export type InternalOption = Require<Omit<Option, 'hook'>, 'baseUrl' | 'timeout'> & {
    hook: Require<Hook>;
    baseUrl: string;
    headers: Require<Headers>;
};

// ---------------------------------------util---------------------------------------
export const isObject = (value: unknown): value is object => value !== null && typeof value === 'object';

export const findUnknownOption = (request: Request, option: Record<string, unknown>): Record<string, unknown> => {
    const unknownOption: Record<string, unknown> = {};
    for (const key in option) {
        if (!(key in REQUEST_OPTION_REGISTRY) && !(key in KR_OPTION_KEY) && !(key in request)) {
            unknownOption[key] = option[key];
        }
    }
    return unknownOption;
};

// `Promise.race()` workaround
export const timeout = (
    request: Request,
    init: RequestInit,
    abortController: AbortController | undefined,
    timeout: number
): Promise<Response> => {
    return new Promise((resolve, reject) => {
        const timeoutId = setTimeout(() => {
            if (abortController) abortController.abort();
            reject(new TimeoutError(request));
        }, timeout);

        void fetch(request, init)
            .then(resolve)
            .catch(reject)
            .then(() => {
                clearTimeout(timeoutId);
            });
    });
};

// ---------------------------------------class---------------------------------------
export class TimeoutError extends Error {
    public request: KrRequest;

    constructor(request: Request) {
        super(`Request timed out: ${request.method} ${request.url}`);
        this.name = 'TimeoutError';
        this.request = request;
    }
}

export const deepMerge = <T>(...sources: Array<Partial<T> | undefined>): T => {
    let returnValue: any = {};
    let headers = {};
    let hooks = {};

    for (const source of sources) {
        if (Array.isArray(source)) {
            if (!Array.isArray(returnValue)) {
                returnValue = [];
            }
            returnValue = [...returnValue, ...source];
        } else if (isObject(source)) {
            for (let [key, value] of Object.entries(source)) {
                if (isObject(value) && key in returnValue) {
                    value = deepMerge(returnValue[key], value);
                }
                returnValue = {...returnValue, [key]: value};
            }
            if (isObject((source as any).hooks)) {
                hooks = mergeHook(hooks, (source as any).hooks);
                returnValue.hooks = hooks;
            }
            if (isObject((source as any).headers)) {
                headers = mergeHeader(headers, (source as any).headers);
                returnValue.headers = headers;
            }
        }
    }
    return returnValue;
};

export const mergeHook = (original: Hook = {}, incoming: Hook = {}): Required<Hook> => {
    const newHookValue = <K extends keyof Hook>(original: Hook, incoming: Hook, property: K) => {
        if (incoming[property] && incoming[property] === undefined) {
            return [];
        } else {
            return deepMerge<Required<Hook>[K]>(original[property] ?? [], incoming[property] ?? []);
        }
    };
    return {
        beforeError: newHookValue(original, incoming, 'beforeError'),
        beforeRequest: newHookValue(original, incoming, 'beforeRequest'),
        afterResponse: newHookValue(original, incoming, 'afterResponse')
    };
};

export const mergeHeader = (source1: KrHeadersInit = {}, source2: KrHeadersInit = {}) => {
    const result = new globalThis.Headers(source1 as RequestInit['headers']);
    const isHeadersInstance = source2 instanceof globalThis.Headers;
    const source = new globalThis.Headers(source2 as RequestInit['headers']);

    for (const [key, value] of source.entries()) {
        if ((isHeadersInstance && value === 'undefined') || value === undefined) {
            result.delete(key);
        } else {
            result.set(key, value);
        }
    }

    return result;
};

export const validateAndMerge = (...sources: Array<Partial<Option> | undefined>): Partial<Option> => {
    for (const source of sources) {
        if ((!isObject(source) || Array.isArray(source)) && source !== undefined) {
            throw new TypeError('The `option` argument must be an object');
        }
    }
    return deepMerge({}, ...sources);
};

class Kr {

    public request: Request;

    protected _input: Input;
    protected _option: InternalOption;
    protected _abortController?: AbortController;

    static create(input: Input, option: Option) {
        const kr = new Kr(input, option);
        const fn = async (): Promise<Response> => {
            if (typeof kr._option.timeout === 'number' && kr._option.timeout > MAX_SAFE_TIMEOUT) {
                throw new RangeError(`The 'timeout' option cannot be greater than ${MAX_SAFE_TIMEOUT}`);
            }
            // Delay the fetch so that body method shortcuts can set the Accept header
            await Promise.resolve();
            let response = await kr._fetch();

            for (const hook of (kr._option?.hook?.afterResponse || [])) {
                const modifyResponse = await hook(
                    kr._option as NormalizeOption,
                    kr.request,
                    kr._decorateResponse(response.clone())
                );
                if (modifyResponse instanceof globalThis.Response) {
                    response = modifyResponse;
                }
            }

            kr._decorateResponse(response);

            if (!response.ok && kr._option.throwHttpError) {
                let error = new HTTPError(kr._option as NormalizeOption, kr.request, response);
                for (const hook of (kr._option?.hook?.beforeError || [])) {
                    error = await hook(error);
                }
                throw error;
            }

            if (kr._option.onDownloadProgress) {
                if (typeof kr._option.onDownloadProgress !== 'function') {
                    throw new TypeError('The `onDownloadProgress` option must be a function');
                }

                if (!supportResponseStream) {
                    throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.');
                }

                return kr._stream(response.clone(), kr._option.onDownloadProgress);
            }

            return response;
        };

        const result: ResponsePromise = fn() as ResponsePromise;

        for (const [key, value] of Object.entries(RESPONSE_TYPE) as ObjectEntry<typeof RESPONSE_TYPE>) {
            result[key] = async () => {
                kr.request.headers.set('accept', kr.request.headers.get('accept') || value);

                const response = (await result).clone();
                if (key === 'json') {
                    if (response.status === 204) {
                        return '';
                    }

                    const arrayBuffer = await response.clone().arrayBuffer();
                    const responseSize = arrayBuffer.byteLength;
                    if (responseSize === 0) {
                        return '';
                    }

                    return JSON.parse(await response.text());
                }

                return response[key]();
            };
        }
        return result;
    }

    constructor(input: Input, option: Option = {}) {
        this._input = input;
        this._option = {
            ...option,
            baseUrl: String(option.baseUrl || ''),
            headers: mergeHeader((this._input as Request).headers, option.headers),
            hook: {
                beforeError: [...(option?.hook?.beforeError || [])],
                beforeRequest: [...(option?.hook?.beforeRequest || [])],
                afterResponse: [...(option?.hook?.afterResponse || [])]
            },
            method: option.method ?? (this._input as Request).method,
            timeout: option.timeout ?? DEFAULT_TIMEOUT,
            throwHttpError: option.throwHttpError !== false
        };

        if (typeof this._input !== 'string' && !(this._input instanceof URL || this._input instanceof globalThis.Request)) {
            throw new TypeError('`input` must be a string, URL, or Request');
        }

        if (this._option.baseUrl && typeof this._input === 'string') {
            if (this._input.startsWith('/')) {
                throw new Error('`input` must not begin with a slash when using `baseUrl`');
            }

            if (!this._option.baseUrl.endsWith('/')) {
                this._option.baseUrl += '/';
            }

            this._input = this._option.baseUrl + this._input;
        }

        if (supportAbortController) {
            this._abortController = new globalThis.AbortController();
            const originalSignal = this._option.signal ?? (this._input as Request).signal;
            originalSignal?.addEventListener('abort', () => {
                this._abortController!.abort(originalSignal.reason);
            });
            this._option.signal = this._abortController.signal;
        }

        if (supportRequestStream) {
            // @ts-expect-error - Types are outdated.
            this._option.duplex = 'half';
        }

        if (this._option.json !== undefined) {
            this._option.body = JSON.stringify(this._option.json);
            this._option.headers.set('content-type', this._option.headers.get('content-type') ?? 'application/json');
        }

        this.request = new globalThis.Request(this._input, this._option);

        if (this._option.parameter) {
            const textSearchParameter: string = typeof this._option.parameter === 'string'
                ? this._option.parameter.replace(/^\?/, '')
                : new URLSearchParams(this._option.parameter as unknown as SearchParameter).toString();

            const url: string = this.request.url.replace(/(?:\?.*?)?(?=#|$)/, ('?' + textSearchParameter));

            // To provide correct form boundary, Content-Type header should be deleted each time when new Request instantiated from another one
            if (
                ((supportFormData && this._option.body instanceof globalThis.FormData)
                    || this._option.body instanceof URLSearchParams) && !(this._option.headers && (this._option.headers as Record<string, string>)['content-type'])
            ) {
                this.request.headers.delete('content-type');
            }

            // The spread of `this.request` is required as otherwise it misses the `duplex` option for some reason and throws.
            this.request = new globalThis.Request(new globalThis.Request(url, {...this.request}), this._option as RequestInit);
        }
    }

    protected _decorateResponse(response: Response): Response {
        response.json = async () => JSON.parse(await response.text());
        return response;
    }

    protected async _fetch(): Promise<Response> {
        for (const hook of (this._option?.hook?.beforeRequest || [])) {
            const result = await hook((this._option as unknown) as NormalizeOption, this.request);

            if (result instanceof Request) {
                this.request = result;
                break;
            }

            if (result instanceof Response) {
                return result;
            }
        }

        const nonRequestOption = findUnknownOption(this.request, this._option);

        // Cloning is done here to prepare in advance for retries
        const mainRequest = this.request;
        this.request = mainRequest.clone();

        if (this._option.timeout === false) {
            return fetch(mainRequest, nonRequestOption);
        }

        return timeout(mainRequest, nonRequestOption, this._abortController, this._option?.timeout || DEFAULT_TIMEOUT);
    }

    protected _stream(response: Response, onDownloadProgress: DownloadProgressHandle) {
        let total = Number(response.headers.get('content-length')) || 0;
        let percent = 1;
        let current = 0;
        if (response.status === 204) {
            if (onDownloadProgress) {
                onDownloadProgress({total, current, percent}, new Uint8Array());
            }
            return new globalThis.Response(
                null,
                {
                    status: response.status,
                    headers: response.headers,
                    statusText: response.statusText
                }
            );
        }

        return new globalThis.Response(
            new globalThis.ReadableStream({
                async start(controller) {
                    const reader = response.body!.getReader();
                    if (onDownloadProgress) {
                        onDownloadProgress({percent: 0, current: 0, total}, new Uint8Array());
                    }

                    async function read() {
                        const {done, value} = await reader.read();
                        if (done) {
                            controller.close();
                            return;
                        }

                        if (onDownloadProgress) {
                            current += value.byteLength;
                            const percent = total === 0 ? 0 : current / total;
                            onDownloadProgress({percent, current, total}, value);
                        }

                        controller.enqueue(value);
                        await read();
                    }

                    await read();
                }
            }),
            {
                status: response.status,
                headers: response.headers,
                statusText: response.statusText
            }
        );
    }

}

// --------------------------------------------kr instance--------------------------------------------

export type KrInstance = {
    /**
     * Fetch the given `url`.
     *
     * ```js
     * import kr from 'kr';
     * const json = await kr('https://example.com', {json: {foo: true}}).json();
     * console.log(json);
     * //=> `{data: '🦄'}`
     * ```
     *
     * @param url `Request` object, `URL` object, or URL string.
     * @param option option
     */<T = any>(url: Input, option?: Option): ResponsePromise<T>;

    /**
     * Fetch the given `url` using the option `{method: 'get'}`.
     *
     * @param url `Request` object, `URL` object, or URL string.
     * @param option option
     */
    get: <T = any>(url: Input, option?: Option) => ResponsePromise<T>;

    /**
     * Fetch the given `url` using the option `{method: 'put'}`.
     *
     * @param url `Request` object, `URL` object, or URL string.
     * @param option option
     */
    put: <T = any>(url: Input, option?: Option) => ResponsePromise<T>;

    /**
     * Fetch the given `url` using the option `{method: 'head'}`.
     *
     * @param url `Request` object, `URL` object, or URL string.
     * @param option option
     */
    head: <T = any>(url: Input, option?: Option) => ResponsePromise<T>;

    /**
     * Fetch the given `url` using the option `{method: 'post'}`.
     *
     * @param url `Request` object, `URL` object, or URL string.
     * @param option option
     */
    post: <T = any>(url: Input, option?: Option) => ResponsePromise<T>;

    /**
     * Fetch the given `url` using the option `{method: 'patch'}`.
     *
     * @param url `Request` object, `URL` object, or URL string.
     * @param option option
     */
    patch: <T = any>(url: Input, option?: Option) => ResponsePromise<T>;

    /**
     * Fetch the given `url` using the option `{method: 'delete'}`.
     *
     * @param url `Request` object, `URL` object, or URL string.
     * @param option option
     */
    delete: <T = any>(url: Input, option?: Option) => ResponsePromise<T>;

    /**
     * Create a new Ky instance with complete new default.
     */
    create: (option?: Option) => KrInstance;

    /**
     * Create a new Ky instance with some default overridden with your own.
     * In contrast to `ky.create()`, `ky.extend()` inherits default from its parent.
     * You can also refer to parent default by providing a function to `.extend()`.
     *
     * ```js
     * import kr from 'kr';
     *
     * const api = kr.create({prefixUrl: 'https://example.com/api'});
     * const userApi = api.extend((option) => ({}));
     * const response = await userApi.get('123');
     * const response = await api.get('version');
     * ```
     */
    extend: (defaultOption: Option | ((parentOption: Option) => Option)) => KrInstance;

};

const createInstance = (defaultOption?: Partial<Option>): KrInstance => {
    const kr: Partial<Mutable<KrInstance>> = (input: Input, option?: Option) => Kr.create(input, validateAndMerge(defaultOption, option));

    for (const method of REQUEST_METHOD) {
        kr[method] = (input: Input, option?: Option) => Kr.create(input, validateAndMerge(defaultOption, option, {method}));
    }

    kr.create = (newDefaults?: Partial<Option>) => createInstance(validateAndMerge(newDefaults));
    kr.extend = (newDefaults?: Partial<Option> | ((parentDefaults: Partial<Option>) => Partial<Option>)) => {
        if (typeof newDefaults === 'function') {
            newDefaults = newDefaults(defaultOption ?? {});
        }
        return createInstance(validateAndMerge(defaultOption, newDefaults));
    };
    return kr as KrInstance;
};

// --------------------------------------------export--------------------------------------------

export const kr: KrInstance = createInstance();
export default kr;
